SPB Git forge
28commits 1branches 0releases
7.7 MBsize
maindefault branch
10 days agolast push
Python 66.3% TypeScript 22.7% JavaScript 8.6% HTML 1.4% CSS 0.7%
5.7 KB · 103 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import Link from 'next/link';3import { notFound } from 'next/navigation';4import { EventEvidence } from '@/components/events/event-evidence';5import { EventList } from '@/components/events/event-row';6import { CountryChip, EventTypeBadge, ImportanceMeter, SignificanceBadge } from '@/components/ui/badges';7import { LiveAgo } from '@/components/ui/live';8import { Container, Note } from '@/components/ui/section';9import { api, ApiError, safe } from '@/lib/api';10import { fmtDateTime, fmtInt } from '@/lib/format';11import { routes, SITE_NAME } from '@/lib/site';12import type { EventDetail } from '@/lib/types';1314export const revalidate = 120;1516async function load(id: string): Promise<EventDetail> {17  try {18    return await api.event(id);19  } catch (e) {20    if (e instanceof ApiError && e.notFound) notFound();21    throw e;22  }23}2425export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {26  const { id } = await params;27  const e = await safe(api.event(id));28  if (!e) return { title: 'Event' };29  return { title: e.title, description: e.summary ?? `${e.event_type} event detected for ${e.company.display_name} on ${fmtDateTime(e.detected_at)}.`, alternates: { canonical: `/events/${e.id}` }, openGraph: { title: `${e.title} | ${SITE_NAME}`, description: e.summary ?? undefined, type: 'article', publishedTime: e.detected_at }, robots: e.status === 'active' ? undefined : { index: false } };30}3132export default async function EventPage({ params }: { params: Promise<{ id: string }> }) {33  const { id } = await params;34  const e = await load(id);35  const related = await safe(api.companyEvents(e.company.slug, { per_page: 6 }));36  const others = (related?.items ?? []).filter((x) => x.id !== e.id).slice(0, 5);37  const jsonLd = { '@context': 'https://schema.org', '@type': 'NewsArticle', headline: e.title, datePublished: e.detected_at, dateModified: e.detected_at, about: { '@type': 'Organization', name: e.company.display_name, url: `https://${e.company.canonical_domain}` }, isBasedOn: e.source_url ?? undefined, publisher: { '@type': 'Organization', name: SITE_NAME } };38  return (39    <Container>40      <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />41      <div className="pb-4 pt-6 md:pt-10">42        <p className="eyebrow flex flex-wrap items-center gap-2">43          <Link href={routes.events()} className="hover:text-ink">44            Events45          </Link>46          <span>/</span>47          <EventTypeBadge type={e.event_type} subtype={e.event_subtype} />48          {e.status !== 'active' && <span className="text-danger">{e.status}</span>}49        </p>50        <h1 className={`display mt-2 text-[26px] md:text-[36px] ${e.status === 'retracted' ? 'line-through decoration-danger/60' : ''}`}>{e.title}</h1>51        <div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-sm text-ink-2">52          <Link href={routes.company(e.company.slug)} className="font-medium text-ink hover:text-accent">53            {e.company.display_name}54          </Link>55          <span className="mono text-xs text-ink-3">{e.company.canonical_domain}</span>56          <CountryChip code={e.company.country} />57          <span className="inline-flex items-center gap-1.5 text-xs text-ink-3">58            <ImportanceMeter importance={e.importance} /> detected <LiveAgo at={e.detected_at} tick={30000} />59          </span>60        </div>61        {e.summary && <p className="mt-4 max-w-3xl text-[16px] leading-relaxed text-ink-2">{e.summary}</p>}62      </div>63      <div className="grid gap-8 lg:grid-cols-12">64        <div className="lg:col-span-8">65          <p className="eyebrow mb-2">Evidence</p>66          <EventEvidence event={e} sources={e.sources} />67          {e.change && (68            <section className="mt-6">69              <p className="eyebrow mb-2">Underlying change</p>70              <div className="flex flex-wrap items-center gap-3 border border-rule p-3 text-sm">71                <SignificanceBadge value={e.change.significance} />72                <span className="tnum text-ink-2">73                  +{fmtInt(e.change.blocks_added)} / −{fmtInt(e.change.blocks_removed)} / ~{fmtInt(e.change.blocks_modified)} blocks74                </span>75                <span className="text-xs text-ink-3">on {e.change.surface} · {fmtDateTime(e.change.detected_at)}</span>76                <Link href={routes.change(e.change.id)} className="link ml-auto text-xs">77                  Open block-level diff →78                </Link>79              </div>80            </section>81          )}82          {Object.keys(e.payload ?? {}).length > 0 && (83            <details className="mt-6">84              <summary className="cursor-pointer text-sm text-ink-2 hover:text-ink">Structured payload & entities</summary>85              <pre className="mt-2 overflow-x-auto border border-rule bg-surface-2 p-3 text-xs">{JSON.stringify({ payload: e.payload, entities: e.entities }, null, 2)}</pre>86            </details>87          )}88        </div>89        <aside className="lg:col-span-4">90          <p className="eyebrow mb-2">More from {e.company.display_name}</p>91          <EventList events={others} variant="table" showCompany={false} emptyLabel="No other events yet." />92          <p className="mt-2 text-sm">93            <Link href={routes.company(e.company.slug, 'timeline')} className="link">94              Company timeline →95            </Link>96          </p>97          <Note className="mt-6">This event is an interpretation of an observed change on a public web page. It links to the page as it was when detected; if the page has since been removed, the observation is preserved in the snapshot archive.</Note>98        </aside>99      </div>100    </Container>101  );102}103